生产实践与 CICD
前面六篇解决了「写得出来、管得住 state」,这篇解决「团队怎么一起用」:规范检查怎么进流水线、变更怎么走 PR 评审、CI 里怎么安全地跑 apply、版本怎么锁。一个人玩 Terraform 怎么顺手怎么来,团队玩就必须有规矩——这篇就是定规矩的。示例基于 Terraform 1.9+,CI 以 GitLab CI 为例,GitHub Actions、Jenkins 思路一致。假设 state 已按 State 状态管理 一篇配置为远程后端。
代码规范流水线
Terraform 代码的规范检查分四层,各管一段,建议全部进 CI:
| 工具 | 定位 | 能查出的问题 |
|---|---|---|
terraform fmt | 官方格式化 | 缩进、对齐、换行不统一 |
terraform validate | 官方语法校验 | HCL 语法错误、引用了不存在的变量或资源 |
| tflint | 第三方静态检查 | 声明了没用的变量、云厂商特有错误(实例类型不存在、废弃写法) |
| checkov | 安全合规扫描 | 安全组放开 0.0.0.0/0、存储桶未加密、缺日志审计 |
逐个说接入姿势。
fmt 用 -check 模式进 CI。本地写代码随手 terraform fmt -recursive 自动修好,CI 里只检查不修改,有文件不合规就挂掉流水线:
# 本地:一键格式化整个项目
terraform fmt -recursive
# CI:只检查,不合规退出码非 0
terraform fmt -check -recursive
validate 需要先 init,但不连云平台。用 -backend=false 跳过后端初始化,CI 里不配云凭据也能跑:
terraform init -backend=false
terraform validate
tflint 按云厂商装插件。项目根目录放一份 .tflint.hcl:
plugin "aws" {
enabled = true
version = "0.32.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
tflint --init # 首次安装插件
tflint # 日常检查
它能查出 validate 查不出的东西,比如 instance_type = "t3.xlarg"(拼错一个字母,AWS 上根本没这型号)、声明了但全文没引用的 variable。
checkov 扫安全底线。它不懂你的业务,但能守住「别把数据库暴露给公网」这类低级红线:
pip install checkov
checkov -d . --framework terraform
把四个命令包成一个 make check 或 pre-commit hook,提交前本地跑完,CI 基本不会红。CI 红了再回头修,一来一回比本地跑十秒贵得多。
PR 工作流
推荐的变更流程三步走:
- 开 PR,CI 自动跑 plan,把结果贴回 PR。评审的人不用拉代码、不用本地跑,直接在 PR 里看「这次要建 2 个、改 1 个、删 0 个」。
- 评审。评审的核心不是代码风格,是 plan 输出——代码写得再漂亮,plan 里冒出意料外的
destroy就不能合。 - 合并后 apply。合并进 main 分支才允许 apply,且 apply 跑的必须是合并后的 main,保证「评审过的 plan」和「实际执行的变更」是同一份。
为什么 plan 结果一定要进 PR 评论?因为 HCL 是声明式的,代码 diff 看不出真实影响:count 改一个数字,diff 只有一行,实际可能是删掉十台机器。plan 输出才是变更的真实面目,评审必须对着它评。
plan 是基于当时的 state 和世界状态算出来的。PR 挂了一周才合并,这一周里别人动过 state、控制台上有人改了资源,旧 plan 就失真了。规矩:apply 前用合并后的代码重新出 plan,隔夜的 plan 文件直接丢弃。
CI 集成示例
下面是一份可以直接抄的 .gitlab-ci.yml,三段式:validate → plan → apply。apply 手动触发,且只允许在 main 分支上跑:
stages:
- validate
- plan
- apply
variables:
# 非交互模式:Terraform 缺输入时直接报错,而不是卡住等输入
TF_INPUT: "false"
# 告诉 Terraform 运行在自动化环境,日志输出更克制
TF_IN_AUTOMATION: "true"
TF_VERSION: "1.9.8"
# Terraform 代码所在目录,按项目实际结构改
TF_ROOT: "environments/prod"
default:
image: hashicorp/terraform:${TF_VERSION}
before_script:
- cd ${TF_ROOT}
- terraform version
fmt:
stage: validate
script:
- terraform fmt -check -recursive ${CI_PROJECT_DIR}
validate:
stage: validate
script:
# 不连后端,validate 不需要云凭据
- terraform init -backend=false
- terraform validate
tflint:
stage: validate
image: ghcr.io/terraform-linters/tflint:latest
script:
- tflint --init
- tflint
allow_failure: true # 刚接入时先告警不卡流程,稳定后改成 false
plan:
stage: plan
script:
- terraform init
- terraform plan -no-color -out=plan.tfplan
artifacts:
paths:
- ${TF_ROOT}/plan.tfplan
expire_in: 1 day
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
apply:
stage: apply
script:
- terraform init
- terraform apply -input=false plan.tfplan
dependencies:
- plan
rules:
# 只在 main 分支出现,且必须手动点击触发
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
environment:
name: production
几个关键点:
TF_INPUT=false必须设。CI 里没有终端,Terraform 一旦尝试交互式提问(比如缺变量)就会无限期挂住,直到流水线超时。设了之后它会立刻报错退出,日志里能看到缺什么。- 认证用 CI 变量注入,不落盘。AWS 的话在 GitLab 项目 Settings → CI/CD → Variables 里加
AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY,勾上 Masked(凭据只该给保护分支用的话再勾 Protected)。state 后端凭据同理,全部走环境变量,.tf文件和 CI 配置里一个明文密码都不写。 - plan 产物用 artifact 传给 apply,保证 apply 跑的就是评审过的那份 plan;
expire_in: 1 day让隔夜 plan 自动失效,配合上一节「别用隔夜 plan」的规矩。 - apply 手动触发 + 限定 main 分支,防止 feature 分支误 apply,也给人工确认留了口子。
- 想把 plan 输出自动评论到 MR 里,用
terraform plan -no-color的输出配合 GitLab 的 MR 讨论 API 写个小脚本即可;嫌麻烦就直接看 plan job 的日志,效果一样。
如果 Terraform 变更的最终目的是发新版容器,apply 之后接 autodok 按需更新容器 是个轻量方案,CI 里 POST 一下就触发更新,不用引入重型 CD。
Atlantis 简介
上面的 GitLab CI 流程够用,但「plan 结果评论回 PR、评论一句话就 apply」这些要自己拼脚本。Atlantis 就是专为这个流程造的工具:自托管一个服务,监听 GitLab / GitHub 的 PR 事件,PR 一开自动跑 plan 并把输出贴回评论;想 apply 就在 PR 里评论一句 atlantis apply,它执行完再把结果贴回来。期间它会锁住对应目录,别人对同一目录开 PR 会提示有冲突在途。
适用判断:团队三人以上、Terraform 目录多、变更频繁,自己维护 CI 脚本开始吃力,就上 Atlantis;一两个人、一周变不了几次,前面那份 GitLab CI 足够了,不必多养一个服务。
版本约束
Terraform 和 provider 都在快速演进,版本不锁等于给未来的自己埋雷。锁定分两处。
required_version 锁 Terraform 本身,required_providers 锁 provider:
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.70"
}
}
}
~> 的语义是「允许最右边那段往上走」:
| 写法 | 等价范围 | 含义 |
|---|---|---|
~> 1.9 | >= 1.9, < 2.0 | 1.x 内随便升 |
~> 1.9.0 | >= 1.9.0, < 1.10.0 | 只允许升 patch |
~> 5.70 | >= 5.70, < 6.0 | AWS provider 5.x 内随便升 |
生产环境推荐 ~> x.y.z 这种三段写法:patch 升级(纯 bug 修复)自动跟上,minor 升级(可能带新行为)手动改版本号、跑过 plan 确认再上。
.terraform.lock.hcl 必须提交 git。terraform init 会生成这个文件,记录每个 provider 的精确版本号和官方校验和。提交它有两个作用:一是团队所有人和 CI 装上一模一样的 provider 版本,杜绝「我本地能跑、CI 挂了」;二是校验和防篡改,init 时发现下载的 provider 和锁文件对不上会直接报错。升级 provider 走显式流程:
terraform init -upgrade
# 检查 .terraform.lock.hcl 的 diff,确认升的是预期版本再提交
git diff .terraform.lock.hcl
.terraform.lock.hcl 里的校验和改一个字符就会导致 init 失败。要换版本就改 required_providers 里的约束再 terraform init -upgrade,让 Terraform 自己重写锁文件。
团队协作守则
最后这几条是血泪经验,新成员入队先读这段:
- 远程 state 是一切协作的前提,还没配的去翻 State 状态管理。本地 state 意味着同一时间只有一个人能干活,且 state 文件丢了等于基础设施「失忆」。
- 禁止在云控制台手改资源。控制台改的东西 Terraform 不知道,下次 plan 会把改动当漂移「纠正」回去——或者更糟,和代码冲突产生意外变更。发现有人手改了,立刻按第 8 篇的漂移处理流程对齐,再决定代码怎么改。
- apply 前必读 plan 全文。重点看三处:
destroy出现的位置是否符合预期、变更数量级对不对(预期改 2 个结果变了 200 个)、有没有forces replacement(强制重建,意味着先删后建)。不读 plan 就敲 yes 等于闭眼开车。 - 生产变更走变更窗口。哪怕 plan 看起来人畜无害,生产 apply 也约在低峰期,窗口内至少两个人在线——一个操作,一个盯监控。
- 一个目录同一时刻只有一个在途变更。CI 串行执行 + state 锁兜底,别两个人同时对同一目录合并 PR。
小结
- 规范检查四层各管一段:
fmt管格式、validate管语法、tflint 管代码质量、checkov 管安全,本地先跑一遍,CI 再卡一道。 - PR 工作流核心:plan 输出必须进 PR 评论,评审对着 plan 评;合并到 main 后才允许 apply,apply 用刚产出的 plan 文件。
- CI 三件套:
TF_INPUT=false、凭据走环境变量、apply 手动触发且限定 main;团队大了再上 Atlantis。 - 版本双锁:
required_version和required_providers用~>约束,.terraform.lock.hcl提交 git。 - 守则一句话:远程 state、控制台零手改、plan 必读、窗口期变更。
下一篇是本系列收官:出问题怎么办——调试手段、常见报错清单、状态漂移修复,以及一份收束全系列的最佳实践清单。